Robust and Modular Exception Handling in NestJS {chatgpt}
ChatGPT Deep Research answer
Introduction
Building a robust error-handling system in NestJS involves more than just throwing generic exceptions. A well-designed approach ensures that errors are consistently formatted, correctly mapped to HTTP responses, and centrally logged. This is especially important in large modular or monorepo architectures, where multiple modules or services need a unified error strategy. NestJS provides an exceptions layer that can be extended to meet these needs. By leveraging custom exception classes, global exception filters, and integrations with tools like Prisma and Sentry, we can create a scalable error-handling pattern that improves maintainability and observability.
Custom Exception Class with Hierarchical Error Codes
Instead of scattering HttpException
instances throughout your code, define a custom exception class (e.g., RacketimeException
) to represent application-specific errors in a structured way. This class can encapsulate a module name, a symbolic error code, and a payload for additional context. By mapping these symbolic identifiers to detailed error definitions, you achieve hierarchical or modular error codes (each module has its own namespace of errors).
Key benefits:
- Clarity: The module name and error symbol make it immediately clear where an error originated and what it means.
- Consistency: All errors follow a structured format (e.g. including an error
code
, human-readablemessage
, etc.). - Maintainability: Error definitions (messages and HTTP statuses) are centralized, avoiding magic strings across the codebase.
Implementing the class: The custom exception should extend NestJS’s HttpException
so it integrates with Nest’s response flow. It can look up an error definition from a mapping. For example:
import { HttpException, HttpStatus } from '@nestjs/common';
interface ErrorInfo {
status: number;
message: string;
}
const ERROR_DEFINITIONS: Record<string, Record<string, ErrorInfo>> = {
// Module-specific error definitions
Auth: {
INVALID_CREDENTIALS: { status: HttpStatus.UNAUTHORIZED, message: 'Invalid credentials provided.' },
USER_NOT_FOUND: { status: HttpStatus.NOT_FOUND, message: 'User not found.' },
// ...other Auth module errors
},
Payment: {
INSUFFICIENT_FUNDS: { status: HttpStatus.BAD_REQUEST, message: 'Insufficient funds for transaction.' },
// ...other Payment module errors
},
// ...other modules
};
export class RacketimeException extends HttpException {
/**
* @param module Name of the module (e.g. 'Auth', 'Payment')
* @param code Symbolic error code (e.g. 'USER_NOT_FOUND')
* @param payload Additional details (optional, used to enrich the message or log context)
*/
constructor(module: string, code: string, payload?: any) {
const moduleErrors = ERROR_DEFINITIONS[module] || {};
const errorInfo = moduleErrors[code];
if (!errorInfo) {
// Fallback for undefined errors
super({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: `Unhandled error ${module}.${code}`,
error: 'InternalServerError'
}, HttpStatus.INTERNAL_SERVER_ERROR);
} else {
// You can incorporate payload data into the message if needed
const { status, message } = errorInfo;
// Include a combined error code (e.g. "Auth.USER_NOT_FOUND") in the response for easy identification
const errorCode = `${module}.${code}`;
super({ statusCode: status, error: errorCode, message, ...(payload && { details: payload }) }, status);
}
}
}
In this design, the ERROR_DEFINITIONS
acts as a registry mapping each module’s error symbols to an HTTP status and a default message. The RacketimeException
constructor uses this to construct a standardized response object which includes the HTTP status code, a combined error code string, and a message. The optional payload
can carry extra context (e.g. an ID that was not found) and can be attached in a details
field for logging or debugging (ensure not to leak sensitive info).
Usage example: If a user lookup fails in the Auth module, you might throw:
throw new RacketimeException('Auth', 'USER_NOT_FOUND', { userId });
This will produce a 404 Not Found response with a JSON body similar to:
{
"statusCode": 404,
"error": "Auth.USER_NOT_FOUND",
"message": "User not found.",
"details": { "userId": "12345" }
}
Such structured errors make it easy for clients to parse the response and for developers to trace the origin. You can also create module-specific subclasses of RacketimeException
for convenience. For example, an AuthException
class could preset the module name so you only pass the code and payload. This pattern scales well in a monorepo: each module can define its error codes (perhaps in a separate file or enum), and all use the common base class for consistency.
Structured error responses: Your custom exception can return an object (as shown above) rather than just a string message. NestJS will serialize this object as the JSON response. The structure can follow a standard like RFC 7807 (Problem Details) or a custom schema. For instance, one could include fields like
title
,status
,detail
, and even an array of sub-errors. In the example above, we includedstatusCode
andmessage
for clarity. An alternative approach is demonstrated by a customNotFoundError
class that includes a title, detail and even anerrors
array for context. By extendingHttpException
and supplying a rich response object, you make error messages more informative and relevant to the specific context.
Mapping Prisma Known Errors to HTTP Exceptions
When using Prisma (an ORM/DB client), certain database errors are common and expected (e.g. unique constraint violations, foreign key violations, record not found). By default, if not handled, these will bubble up as unhandled exceptions and NestJS will treat them as internal server errors (HTTP 500). We want to intercept these and translate them into appropriate HTTP responses. This is best done with a global exception filter that specifically catches Prisma errors.
Prisma’s error format: Prisma throws a PrismaClientKnownRequestError
for known errors. This exception includes a .code
property (like "P2002"
) that identifies the error type. Some common Prisma error codes and their meaning:
- P2002 – Unique constraint failed (violated a unique index) – should be an HTTP 409 Conflict.
- P2025 – Record not found (e.g. for an update or delete where no row exists) – should be 404 Not Found.
- P2000 – Value too long for a column – can be 400 Bad Request (client sent invalid data).
- P2003 – Foreign key constraint failed – often a 400 Bad Request or 409 Conflict (depending on context; typically means the related record doesn’t exist or constraint blocked the operation).
These mappings align with best practices and the default suggestions from the community. For instance, the
nestjs-prisma
package’s built-in exception filter mapsP2002
to 409 Conflict andP2025
to 404 Not Found by default.
Implementing a global Prisma exception filter: We can create a global exception filter that catches all exceptions, checks if it's a Prisma KnownRequestError
, and maps it accordingly. This filter will prevent those errors from reaching the default handler (which would return 500). Instead, it will produce a controlled HttpException.
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import { Prisma } from '@prisma/client'; // PrismaClientKnownRequestError is under Prisma namespace
@Catch() // Catch every exception
export class AllExceptionsFilter extends BaseExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
// If the exception is a Prisma known client error, map it to an HttpException
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
const prismaError = exception;
switch (prismaError.code) {
case 'P2002':
exception = new ConflictException('Unique constraint violation.');
break;
case 'P2025':
exception = new NotFoundException('Record not found.');
break;
case 'P2000':
exception = new BadRequestException('Value too long for field.');
break;
case 'P2003':
exception = new BadRequestException('Invalid reference specified.');
break;
// ... handle other Prisma error codes as needed
default:
// For unmapped codes, you might choose a generic error:
exception = new HttpException('Database error', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
// (Optional) Add any other custom exception mapping logic here...
// e.g., transform validation errors or other library errors if needed.
// Finally, delegate to Nest's base exception filter for standard processing
super.catch(exception, host);
}
}
A few notes on this implementation:
- We use
@Catch()
with no specific type, meaning this filter will catch every exception not already handled by more specific filters. Inside, we explicitly check forPrismaClientKnownRequestError
to handle those differently. - The filter extends
BaseExceptionFilter
from@nestjs/core
. This is a useful trick: by callingsuper.catch(exception, host)
after we’ve done our processing, we leverage the default NestJS behavior for all other cases. Nest’s base filter will handle standardHttpException
(by returning the response we set up in it) or return a 500 for unknown errors. By converting Prisma errors intoHttpException
(likeConflictException
orNotFoundException
), we ensure the base class will now output the proper status code and message instead of a generic 500. - The mapping shown covers a few common Prisma errors. You should extend this as needed for your application (e.g., handling other
P2xxx
codes). Prisma’s documentation lists all error codes, and it’s wise to handle those that are likely in your context. As recommended in a Prisma/NestJS guide, adding a case forP2025
(record not found) to return 404 is important for endpoints that expect a record to exist.
After defining this filter, register it globally so that it applies to all endpoints. You can do this in your main bootstrap function:
// main.ts
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new AllExceptionsFilter());
// ... listen, etc.
This will ensure any exception thrown in any module of the app goes through our AllExceptionsFilter
. (Alternatively, you can provide it in the AppModule with the APP_FILTER
token, which accomplishes the same global scope.) Once applied, if you attempt an operation that violates a unique constraint or requests a missing record, the response will be a neatly formatted 409 or 404 instead of a raw 500 error. For example, without handling, a duplicate database entry would trigger a Prisma error and yield a 500; with our filter, the client gets a 409 Conflict with an explanatory message.
Tip: The
nestjs-prisma
library offers a ready-madePrismaClientExceptionFilter
with default mappings. Using such a package can reduce boilerplate. However, implementing it yourself (as above) provides more control and the ability to integrate with other logic (like logging or Sentry) in the same filter.
Centralized Sentry Error Tracking
In a large application, it's critical to log exceptions for debugging and monitoring. Sentry is a popular error tracking service. Instead of sprinkling Sentry.captureException(error)
calls in every catch
block (which is tedious and error-prone), we can leverage the same global exception filter to handle Sentry logging in one place.
Integrating Sentry in the global filter: We can enrich our AllExceptionsFilter
(or create a dedicated global filter) to capture all unhandled exceptions to Sentry. NestJS’s filter mechanism is a convenient hook to send errors to Sentry as they occur. For example:
import * as Sentry from '@sentry/node';
@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
// Log every exception to Sentry (adjust filtering as needed)
Sentry.captureException(exception);
// ... (Prisma mapping logic from above can be here) ...
super.catch(exception, host);
}
}
In the code above, the call to Sentry.captureException(exception)
will push the exception (along with its stack trace) to Sentry. This happens for any error, whether it’s a handled HttpException
or an unexpected error, because our filter catches everything. We then pass the exception to super.catch
to continue normal HTTP response processing. This pattern ensures you capture the error once globally, rather than in every service or controller method. A similar approach is shown in a custom Sentry exception filter example: the filter calls Sentry.captureException(exception)
inside its catch
method, before delegating to the base handler. As the accompanying explanation notes, “Finally, the exception is sent to Sentry for tracking.”.
Avoiding noise: While capturing all exceptions is straightforward, you might not want to send every single error to Sentry. For example, a client requesting a non-existent resource (404) or sending bad data (400) might be a routine occurrence and not indicative of a bug in your system. Flooding Sentry with such expected errors can make it harder to spot real problems. Consider refining the capture logic:
- You could skip logging for certain HttpException status codes (e.g., don’t send 404 NotFound or 400 BadRequest to Sentry, but do send 500 InternalServerError, 503 ServiceUnavailable, etc.).
- Alternatively, use Sentry “severity” or tags to differentiate expected errors from critical ones.
For instance:
if (exception instanceof HttpException) {
const status = exception.getStatus();
if (status >= 500) {
Sentry.captureException(exception); // Only capture server errors
}
} else {
Sentry.captureException(exception); // It's not an HttpException, definitely capture it
}
You can adjust this logic based on your needs. In many cases, capturing all exceptions (including 4xx) is still useful during development or early testing, and you can dial it back in production.
Adding context: Sentry allows attaching extra context to error reports. In a NestJS filter, you have access to the ArgumentsHost
, which can give you the HTTP request details. Using Sentry.configureScope
or Sentry.withScope
, you can add data like the URL, params, user info, etc., to each error report. For example, one approach captures the request URL, method, headers, body, and even authenticated user info if available, before calling captureException
. This enriches the Sentry logs tremendously, making debugging easier. While adding such context is optional, it's a recommended practice for a production setup.
Initialize Sentry once: Ensure you initialize the Sentry SDK at application startup (e.g., in main.ts
or in a dedicated provider). Set the DSN, environment, and other options as needed (sample rate, traces, etc.). For example, using Sentry.init({ dsn: 'your-dsn', environment: process.env.NODE_ENV, ... })
. Only after initialization will Sentry.captureException
actually send data. Also, include the global filter provider (APP_FILTER
) or useGlobalFilters
registration so that Sentry capturing filter is active. The NestJS official docs also mention a SentryGlobalFilter
that can be used if you don’t write a custom one, but our custom approach gives more flexibility.
Capturing original errors: If you are wrapping low-level errors into higher-level exceptions (for example, catching a low-level error and throwing a new
HttpException
with a sanitized message), be mindful that you might lose the original stack trace. A common scenario is throwing a simplified error for the client, but then Sentry only sees that simplified error, not the root cause. One article described this pitfall – the developer was sending a processed error to Sentry instead of the actual raw error. To avoid this, capture the original error before you transform or rethrow it. In our Prisma example above, we callSentry.captureException(exception)
prior to replacing it with a new HttpException. This way, Sentry logs the true database error (with details like query and parameters) while the client only sees the friendly message. Another approach is to use thecause
property of JavaScript errors (Node 16+ allows anoptions
parameter with{ cause: originalError }
when constructing a new Error), though NestJS HttpException may not natively expose it. In practice, explicitly capturing the original as shown is simplest.
By centralizing Sentry logging in the global filter, you ensure every unhandled error is reported exactly once, making debugging and monitoring far easier than manual logging scattered across the codebase.
Scaling the Pattern in Modular and Monorepo Setups
Designing your exception handling with modularity in mind pays off when your application grows. Here are some architectural recommendations for scaling this pattern:
-
Module-specific Exception Classes or Factories: For each module in your application (e.g., UserModule, OrderModule), you can create a thin wrapper around the base
RacketimeException
to make throwing errors more convenient. For example,UserException
could be a class that extendsRacketimeException
and automatically passes'User'
as the module name. This avoids repeating the module string in every throw and localizes error definitions. Each module might also maintain its own enum or constants for error codes, which improves readability (e.g.,UserErrorCode.UserNotFound
). The core logic, however, remains in the shared base class and global filter. -
Shared Error Library in Monorepo: In a monorepo with multiple NestJS services (microservices), factor the exception classes and filters into a shared library. NestJS’s monorepo tool (or Nx workspaces) supports libraries that can be imported into different apps. You can create a library (e.g.,
@yourorg/nestjs-errors
) that exports yourRacketimeException
, any pre-defined error maps, and the global exception filter (with Prisma and Sentry integration). Each service can then useapp.useGlobalFilters(new AllExceptionsFilter())
from that shared library and throw the common exceptions. This ensures consistency: all services report errors in the same structure and map internal errors to similar HTTP responses. It also means updates to error handling (new mappings, improved logging) can be made in one place. -
Avoiding Code Duplication: If each module/service defines errors independently, coordinate the naming or coding scheme for errors. A hierarchical code convention (like prefixing error codes with a module identifier) helps avoid collisions and makes it clear where an error came from. For example, you might use error codes like
AUTH_001
,AUTH_002
for Auth module,ORDER_001
for Order module, etc., or simply the approach we showed:<Module>.<Symbol>
in responses. This also aids in searching through logs or Sentry for all errors of a certain module. By centralizing the format (if not the definitions), the global filter can even perform module-specific logic if needed (though usually it wouldn't need to, as the exceptions themselves carry the correct status and message). -
Consistent Response Format: Decide on an error response structure and apply it across all modules. Whether you choose the simple
{ statusCode, message, error }
format (like NestJS default) or a more detailed JSON (like our custom structure withdetails
or the RFC 7807 style), make sure every module's errors conform. This consistency is helpful for API clients (they can parse errors uniformly) and for developers (no need to handle different error shapes). OurRacketimeException
ensures that consistency by funneling everything through one class. If some modules have very unique error needs, you can still subclass or override as necessary, but keep the outward format the same. -
Global vs. Module-level Handling: Sometimes a particular module might need to handle an exception in a special way (maybe to add logging or convert an external library's errors). NestJS allows you to bind exception filters at the controller or method level, but using too many different filters can get messy. A better approach in a modular system is to hook into the global filter logic for module-specific cases. For example, if one module uses a different ORM or a REST SDK that throws its own errors, you can extend the global filter to check
instanceof ThatSdkError
and translate it, much like we did for Prisma errors. This keeps all mappings in one place (the global filter), which is easier to manage than many scattered try/catch blocks or dedicated filters. -
Sentry and Monitoring for Multiple Modules: With a shared global filter, you get unified Sentry logging. You might want to tag errors in Sentry with the module name or error code, which you can do in the filter. For instance, when capturing, if the exception is an instance of
RacketimeException
, you know it has anerror
property in its response (like "Auth.USER_NOT_FOUND"); you could doSentry.setTag("error_code", errorCode)
orSentry.setTag("module", moduleName)
using data you embed in the exception. This makes it possible to filter Sentry issues by module or error type. In a monorepo with multiple services, each service might have its own Sentry project or use a unified project with service tags — either way, the approach stays the same. The key is you don’t duplicate Sentry setup in each module beyond initializing it; the capture logic is in one place.
Use cases and example scenario: Imagine an e-commerce monorepo with separate services for Users, Products, Orders, etc. Each has its own NestJS module or microservice. By using a shared error handling pattern:
- The Users service throws
new RacketimeException('User', 'EMAIL_TAKEN')
when a duplicate email signup is attempted. Internally, this maps to a 409 Conflict with a clear message. The global filter logs it to Sentry and the client gets a standardized error response. - The Orders service might catch a payment gateway error and throw
new RacketimeException('Order', 'PAYMENT_FAILED', { gateway: 'Stripe' })
. The filter might not have special logic for it (since it's already an HttpException by design), so it just goes out as a 502 Bad Gateway (for example) as defined inERROR_DEFINITIONS
for Order module, and Sentry records the event. - If down the line, you switch the database from Prisma to another, you can replace or extend the global filter’s logic without touching the business logic in controllers or services. The modules remain focused on throwing the appropriate
RacketimeException
or other HttpExceptions, and the filter takes care of the rest.
By following these patterns, adding new modules or even new applications to the monorepo becomes easier. They can all leverage the established exception system with minimal setup, ensuring that your error handling remains robust, DRY (Don't Repeat Yourself), and easy to reason about across the entire NestJS architecture.
Conclusion
Designing a modular exception handling system in NestJS involves creating a language of errors for your application (via custom exception classes and codes) and a central translator of those errors (via a global exception filter). We achieved this with RacketimeException
to encapsulate module-specific error details, and a global filter that maps low-level exceptions (like Prisma errors) to high-level HTTP responses and logs everything to Sentry. The result is a clean separation of concerns:
- Developers throw meaningful exceptions without worrying about HTTP specifics or logging.
- The global filter uniformly handles how these exceptions are presented to clients and logged for diagnostics.
This setup is highly extensible – you can plug in new error sources (another DB, an external API error) into the filter, and define new error codes in the mapping as your application grows. By centralizing and standardizing error handling, you not only reduce the risk of overlooked errors (no more missing try/catch
in some corner), but also make the system’s behavior more predictable. As a best practice, always document your error codes and responses for consumers of your API, and monitor your Sentry (or other logging system) to refine which errors are being thrown and handled. With the outlined approach, your NestJS application will handle exceptions “like a pro” – providing clear client feedback while keeping the engineering team informed of real issues behind the scenes.
Sources:
- NestJS Documentation – Exception Filters and HttpException handling
- Prisma Error Reference – Common error codes like P2002 (Unique constraint)
- NestJS Prisma Package – Recommended mappings for Prisma errors (P2000, P2002, P2025)
- Ayoub Khial’s API Error Handling Done Right (Nest.js) – Example of custom HttpException for resource not found
- Enhancing Error Logging in NestJS with Sentry (Dev.to) – Using a global filter to capture exceptions in Sentry
- Stackademic Blog – Handling Errors Like a Pro: Capturing original errors vs. sanitized errors in Sentry